iT邦幫忙

2022 iThome 鐵人賽

DAY 10
0
自我挑戰組

向網頁施點魔法粉 framer-motion 系列 第 10

#10 Ok,Bye... AnimatePresence Page Transition

  • 分享至 

  • xImage
  •  

上一篇使用 <AnimatePresence> 都是直接底下包裹 motion 元件, 實際上我們更常把元件打包起來成客製化元件 (<Item> 這樣) ,如果想在元件使用 <AnimatePresence> 有些要注意的地方。今天也會加上 react-router做頁面轉場的效果,有了轉場動畫,整個體驗就很不一樣了。

本節資源 :
程式碼
網頁展示

目錄

  1. 下台一鞠躬 : 自製元件離場動畫
  2. 是 modal 不是 model : 跳窗
  3. 基本設置 react-router
  4. 加上轉場 !

下台一鞠躬 : 自製元件離場動畫

如果要應用離場動畫,至少內部要有一個是 motion 元件並且含有 exit prop。另一個要求自製元件一定是 AnimatePresence 的下一層 (direct descendant),什麼意思呢 ?

一定要包 DOM 節點消失的那個物件,不是內層的物件。

// 可以
export const MyComponent = ({ items }) => (
  <AnimatePresence>
    // 下一層
    {items.map(({ id }) => (
      <Item key={id} />
    ))}
  </AnimatePresence>
)
// 不行
export const MyComponent = ({ items }) => (
  <AnimatePresence>
    // 下一層
    <div key={id} >
      {items.map(({ id }) => (
        <Item />
      ))}
    </div>
  </AnimatePresence>
)

是 modal 不是 model : 跳窗

範例來自 : How To Make a Modal Box With CSS and JavaScript

主要達成只有兩個 :

  1. 點擊叉叉關掉視窗
  2. 點擊 modal 以外的黑幕關掉視窗

會使用到 ReactDOM 的 createPortal ,其原因 React 官方說明的很明確 :

一個典型的 portal 使用案例是,當 parent component 有 overflow: hidden 或者 z-index 的樣式時,卻仍需要 child 在視覺上「跳出」其容器的狀況。例如 dialog、hovercard 與 tooltip 都屬於此案例。

基本知識了解後就來實作了 :D。

  • useToggle : 之後有關 開關的元件就直接使用了,在 /Hooks/useToogle
import { useState } from "react";

export default function useToggle(initialType = false) {
    const [toggle, setToggle] = useState(initialType);

    const handleToggle = (boolean) => (e) => {
        // 什麼都不傳或 null 就是開開關關
        if (boolean === null || boolean === undefined) {
            setToggle((boolean) => !boolean);
            return;
        }
        // 指定操作
        setToggle(boolean);
    };
    return {
        toggle,
        handleToggle,
    };
}
  • 記得要在 public/index.html 加上等等要 portal 的容器
<!-- 一般元件的生長的位置  -->
<div id="root"></div> 

<!-- 拿來裝 modal 的容器  -->
<div id="modal-root"></div>
  • 先做好一個 modal,基本上都是從 W3school 複製過來的
// CSS
.modal-Box{
    position: absolute;
    top:0;
    left:0;
    right: 0;
    bottom: 0;
    display: flex;
    justify-content: center;
    align-items: center;
}

.modal-content {
    background-color: #fefefe;
    margin: 15% auto;
    padding: 20px;
    border: 1px solid #888;
    width: 80%;
    z-index: 1;
}

.close {
    color: #aaa;
    float: right;
    font-size: 32px;
    font-weight: bold;
}

.overlay{
    position: absolute;
    width: 100%;
    height: 100%;
    background-color: rgba(0,0,0,0.4);
}

// JS

// 引入 動畫元件
import { AnimatePresence, motion } from "framer-motion";
import React from "react";
// 引入 ReactDOM 要使用 createPortal 
import ReactDOM from "react-dom";
// 引入 Hooks
import useToggle from "../../../Hooks/useToggle";
// 引入動畫的 variants
import { modalVariants, modalBoxVariants } from "./animate";
import "./Modal.style.css";

function Modal() {
    // 冒號只是另外取名而已
    const { toggle: open, handleToggle: handleOpen } = useToggle(false);
    return (
        <>
            // 因為要展示,所以外部有一個作為開關用的
            <button onClick={handleOpen(true)}>
                Open Modal
            </button>
            {/* 離場動畫判斷 */}
            <AnimatePresence initial={false}>
                // 把 function 傳進入
                {open && <ModalBox handleOpen={handleOpen} />}
            </AnimatePresence>
        </>
    );
}


const ModalBox = ({ handleOpen }) => {
    return ReactDOM.createPortal(
        <div className="modal-Box">
            // 占全版的黑幕
            <motion.div
                className="overlay"
                onClick={handleOpen(false)}
                variants={modalVariants}
                initial="hidden"
                animate="show"
                exit="hidden"
            />
            
            // modal 本體
            <motion.div
                className="modal-content"
                variants={modalBoxVariants}
                initial="hidden"
                animate="show"
                exit="hidden"
            >
                <div className="modal-header">
                    // 叉叉加入事件與 hover 動畫 
                    <motion.span
                        className="close"
                        onClick={handleOpen(false)}
                        whileHover={{
                            color: "red",
                            cursor: "pointer",
                        }}
                    >
                        ×
                    </motion.span>
                    <h2>Modal Header</h2>
                </div>
                <div className="modal-body">
                    <p>Some text in the Modal Body</p>
                    <p>Some other text...</p>
                </div>
                <div className="modal-footer">
                    <h3>Modal Footer</h3>
                </div>
            </motion.div>
        </div>,
        document.getElementById("modal-root")
    );
};
  • 效果 :

更客製化的話可以定義一些 props 跟在 motion 元件上使用 custom,傳入動態的動畫數值。

基本設置 react-router

react-router 我用過次數大概一隻手可以算出來,如果有誤的話,再麻煩高手指點一下 Q
如果是在個人專案使用,這段可以跳過,並到下面實作的部分。

npm install react-router-dom@6
  • src/index.js 最外處加入 <BrowserRouter> 元件,設置好路由起點
// 引入 react-router-dom
import { BrowserRouter } from "react-router-dom";
// 主要部分
root.render(
    <React.StrictMode>
        <BrowserRouter>
            <App />
        </BrowserRouter>
    </React.StrictMode>
);
  • src/App.js加上 <Routes> 並且設好 <Route>
import { Routes, Route } from "react-router-dom";

function App() {
    const [day, setDay] = useState(Object.keys(Days).length + 1);
    
    return (
        <div>
            <label htmlFor="days">鐵人賽第 {day} 天</label>
            <select
                id="days"
                value={day}
                onChange={(e) => setDay(e.target.value)}
            >
                {Object.keys(Days).map((_, i) => {
                    return (
                        <option key={i} value={i + 2}>
                            Day {i + 2}
                        </option>
                    );
                })}
            </select>
            {/* 因路徑改變的元件在這裡~ */}
            <Routes>
                {/* :slug 是對應網址 */}
                <Route path="Day:day" element={<DailyTemplate />} />
            </Routes>
        </div>
    );
}
  • 以往都會用 <Link> 元件做導向,但這邊我是用下拉選單選取,所以用 usenavigate
// 引入 useNavigate
import { Routes, Route, useNavigate } from "react-router-dom";

const navigate = useNavigate(); // 加上 Hooks

// 當 day 改變時就導到對應網址
useEffect(() => {
    navigate(`/Day${day}`);
}, [day, navigate]);
  • 由於我的模板都是同一個,沒有分多個元件,要判斷網址後面的參數 :day 來決定載入的 animation 元件 ,在 template/DailyTemplate.js 補上
import { useParams } from "react-router-dom"; // 引入 Hooks

let { day } = useParams(); // 抓網址後的參數

到這邊我們完成了基本的設置,我的架構都一改再改,每天打開看就是手很癢阿阿阿 !

加上轉場 !

  • location 具有獨特的 key ,要把它拿來使用
// 引入 useLocation
import { Routes, Route, useNavigate, useLocation } from "react-router-dom";

const location = useLocation();
// 加上 AnimatePresence 並且取消第一次的 initial,mode="wait" 會等到所有的 exit 結束
<AnimatePresence initial={false} mode="wait">
    // 為 Routes 加上 key 每次都能刷新
    <Routes key={location.key} location={location}>
        <Route path="Day:day" element={<DailyTemplate />} />
    </Routes>
</AnimatePresence>
  • 製作一個全版覆蓋的轉場 ,做出油漆刷滿幕的感覺
const containerVariants = {
    exit: {
        top: ["100%", "0%", "0%"],
        bottom: ["0%", "0%", "100%"],
        transition: {
            type: "tween",
            ease: "backInOut",
            duration: 2,
        },
    },
};

// 
 <React.Fragment>
  <motion.div
      variants={componentVariants}
      initial="hidden"
      animate="show"
      exit="exit"
      style={{
          opacity: 1,
      }}
>
      {data &&
          data.map((animation) => {
              const { name, component } = animation;
              let animationComponents;
              if (Array.isArray(component)) {
                  animationComponents = (
                      <>
                        {component.map((c, i) => {
                            const AnimationComponent = c;
                            return (
                                <AnimationComponent
                                    key={name + i}
                                />
                            );
                        })}
                      </>
                  );
              } else {
                  const SingleComponent = component;
                  animationComponents = <SingleComponent />;
              }

              return (
                <div
                    key={name}
                    className="container"
                    style={{
                        height: animation.containerHeight ?? "auto",
                    }}>
                    <h3>{name}</h3>
                    <InitialButton>
                        {animationComponents}
                    </InitialButton>
                </div>
              );
          })}
  </motion.div>
  <motion.div
      layout="size"
      className="transitionOverlay"
      variants={containerVariants}
      initial="hidden"
      animate={"exit"}
      exit={"exit"}
  />
</React.Fragment>
  • 效果 : 從 Day9 跨到 Day10

總結

在 react-router 跟一般的元件其實操作是一樣的,同樣也是換掉元件的概念哦 ! 有點來不及補 mode 的部份,晚點會在補在這篇。

沒想到我能到第 10 天,再...再 2 個 10 天 ! 所有的文章內容也正在滾動式修正了 QQ,如果有看到新變動都是很正常的。

參考資料

  1. 官方文件 : AnimatePresence | Framer for Developers
  2. react-router 文件 : Home v6.4.1 | React Router

上一篇
#09 Bye Bye Bye ! AnimatePresence Component
下一篇
#11 Line up ! Reorder Component
系列文
向網頁施點魔法粉 framer-motion 15
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言